為了盡量降低編譯執行計畫所產生的額外負擔,執行計畫會儲存在一個稱為「計畫快取(Plan Cache)」的記憶體空間中。
當使用預備陳述式(Prepared Statements)、預存程序(Stored Procedures),或其他建立參數化查詢的機制時,系統便能重複利用快取中的執行計畫。
然而,有許多情況可能導致執行計畫從快取中被移除。有時候這是好事,例如資料已經變更、統計資料已經更新,或系統發生某些變化,使得採用不同的執行計畫可能改善效能。
但有時候這也可能是壞事。系統可能發生大量重新編譯,對處理器造成過高負載,並干擾系統中查詢原本正常且良好的執行行為。
上一篇說明如何盡可能地降低編譯的情況發生,這裡會講重新編譯的好壞處、如何識別正在重新編譯的語法、分析重新編譯的原因、避免重新編譯的方法
編譯是一個成本很高的操作,但是隨資料時間的改變,統計資料也會改變、資料分布改變、新增索引或條件約束等等因素,最佳化器為了找到更好的執行計畫,重新編譯是必然發生的事情。
還有,因為重新編譯都是在查詢層級,而不是整個預存程序,因此會產生兩個結果
然後再 Quert Store 那邊提到過的強制執行計畫,如果有套用,標準的重新編譯其實還是會進行只是編譯後不套用,她會套用強制執行的那一個計畫。
這是因為如果被強制的那個計畫因為結構性變更或其他原因被標記成無效,系統就會改用重新編譯的那個執行計畫。
所以再重新編譯的角度來看,強制執行計畫在這裡沒有幫助。
--為了說明重新編譯可能帶來的好處,先建立一個示範 sp
CREATE OR ALTER PROCEDURE dbo.WorkOrder
AS
SELECT
wo.WorkOrderID AS N'工單編號',
wo.ProductID AS N'產品編號',
wo.StockedQty AS N'入庫數量'
FROM Production.WorkOrder AS wo
WHERE wo.StockedQty BETWEEN 500 AND 700;

如果去執行她就會看到這個執行計畫。
首先這個計畫非常爛,我會在下一篇討論索引效能,這裡只要先知道這個計畫超爛。
最佳化在執行計畫上面給了一個遺漏索引提示,她建議建立一個索引,不過她建議的不是我接下來要做的這個。
--建立索引之後再跑一次 sp
CREATE INDEX IX_Test
ON Production.WorkOrder
(
StockedQty,
ProductID
);

當索引建立完成後,SQL Server 會自動將所有參考 Production.WorkOrder 資料表的執行計畫標記為需要重新編譯。
這表示最佳化工具現在可以在重新產生執行計畫時,評估是否使用剛建立的新索引。
直到真的執行的時候,才會真的重新編譯。
在這裡她會去重新編譯的主要原因之一,是統計資料更新。
--示範玩了 把她刪掉
DROP INDEX Production.WorkOrder.IX_Test;
--下一個示範
CREATE OR ALTER PROCEDURE dbo.WorkOrderAll
AS
-- 此處刻意使用 SELECT * 作為範例
SELECT *
FROM Production.WorkOrder AS wo;
這邊我故意用 SELECT * ,這樣為了滿足這個查詢,最佳化只有一條路就是 INDEX SCAN。
在執行這個 SP 之前,先建立一個擴充事件,來擷取重新編譯
CREATE EVENT SESSION [QueryAndRecompile]
ON SERVER
ADD EVENT sqlserver.rpc_completed
(
WHERE ([sqlserver].[database_name] = N'AdventureWorks2022')
),
ADD EVENT sqlserver.rpc_starting
(
WHERE ([sqlserver].[database_name] = N'AdventureWorks2022')
),
ADD EVENT sqlserver.sp_statement_completed
(
WHERE ([sqlserver].[database_name] = N'AdventureWorks2022')
),
ADD EVENT sqlserver.sp_statement_starting
(
WHERE ([sqlserver].[database_name] = N'AdventureWorks2022')
),
ADD EVENT sqlserver.sql_batch_completed
(
WHERE ([sqlserver].[database_name] = N'AdventureWorks2022')
),
ADD EVENT sqlserver.sql_batch_starting
(
WHERE ([sqlserver].[database_name] = N'AdventureWorks2022')
),
ADD EVENT sqlserver.sql_statement_completed
(
WHERE ([sqlserver].[database_name] = N'AdventureWorks2022')
),
ADD EVENT sqlserver.sql_statement_recompile
(
WHERE ([sqlserver].[database_name] = N'AdventureWorks2022')
),
ADD EVENT sqlserver.sql_statement_starting
(
WHERE ([sqlserver].[database_name] = N'AdventureWorks2022')
)
ADD TARGET package0.event_file
(
SET filename = N'QueryAndRecompile'
)
WITH
(
TRACK_CAUSALITY = ON
);
GO
ALTER EVENT SESSION QueryAndRecompile
ON SERVER
STATE = START;
當這個 Extended Events 工作階段啟動並開始運作後
--先執行一次
EXEC dbo.WorkOrderAll;
GO
CREATE INDEX IX_Test
ON Production.WorkOrder
(
StockedQty,
ProductID
);
GO
EXEC dbo.WorkOrderAll;
-- 建立 IX_Test 索引之後再次執行

可以看這個索引對整個查詢並沒有影響,但是因為統計資訊變了,所以最佳化還是重新去編譯這個 sp 查詢,浪費時間,結果都一樣。
在正常的 sp 中,通常會包含很多 select 之類的東西,要所以要精準判斷到底是哪一條在發生重新編譯,並不容易。
這也是為什麼我建立了那一個擴充事件。
那個擴充事件會結取 :
透過這些事件,就可以把整個執行流程串在一起,判斷是哪一條陳述式觸發重新編譯。
接下來就要詳細的說明這個擴充事件做了什麼
第一個事件是 sql_batch_starting ( 上面那個 set statistics xml on 那是額外開統計的 跟這裡要說得無關 )
整個流程如下
EXEC dbo.WorkOrderAll;
2. sql_statement_starting
batch 中的陳述式開始執行,因為只有一條陳述式
```sql
EXEC dbo.WorkOrderAll;
SELECT *
FROM pRODUCTION.WorkOrder;
但是,因為先前有做過建 index 這個動作,所以 schema 變了,就被標註成需要重新編譯。
sql_statement_recompile
發生陳述式層級重新編譯
這邊重新編譯的原因她會寫 : schema changed
第二次 sp_statement_starting
sp 陳述式重新執行
因為第一次開始執行時,被重新編譯事件中斷,所以重新編譯完成後,SQL Server 再次啟動這條陳述式。
sp_statement_completed
第一個完成 這是內部 select * 這個完成
sql_statement_completed
第二個完成,這是外部 BATCH SP 完成
sql_batch_completed
整個 batch 完
上面擷取這麼多只是為了演示一遍 sp 實際再運作的流程。
一般在找問題的時候,只會擷取 sql_statement_recompile 而已
這裡就有一個小東西要分享
一般來說 recompile 確實都包在查詢裡面發生,所以去擷取的時候可以看到 statement 裡面寫這次重編譯是從哪一個查詢來的。
但是也有例外就是我現在舉的例子
statement 是 null
所以這時候就要把完整的事件都抓進來看,你才有可能看出這個重編譯是從哪一個查詢來的。
還有,由於這是測試的環境,所以可以很簡單的用眼睛去看 timestamp 順序,就看出整個事件的流程。
但在正式環境中,需要利用 Causality Tracking 把事件分組排序,才能找到要看的地方。
雖然重新編譯可以透過建立更合適的執行計劃來改善效能,但重新編義也完全可能變得過度頻繁,並嚴重影響效能。
每一次執行計畫編譯都會消耗寶貴的 cpu 時間。還有執行計畫也會在記憶體中移來移去,這也是需要代價。
基於以上原因,我們應該了解那些條件會導致重新編譯、何時發生。
造成陳述式層級重新編譯的部分原因如下 :
sp_recompile,會導致重新編譯。RECOMPILE 查詢提示,其效果正如名稱所表示的那樣。PSP跟OPO之後會再說,現在只需要知道,這兩個東西會讓一個查詢對應多個執行計畫,但是這些執行製化的編譯規則,還是跟前面一樣,沒有別的特殊狀況。
--這可以看一下所有重新編譯的原因
SELECT dxmv.map_value
FROM sys.dm_xe_map_values AS dxmv
WHERE dxmv.name = 'statement_recompile_cause';
這個原因會因為 SQL Server 不斷加入新功能,這份清單也會持續增加。
要討論每一個細項會佔用太多篇幅,有興趣就 AI 問一問就好
絕大多數都很容易理解 例如
在一個 batch 中,動態建立資料庫物件,然後在後續陳述式中使用這些物件,是非常常見的情況。
當這類 batch 第一次執行時,初始執行計畫不會包含正在建立之物件的相關資訊。其他處理策略會延後到查詢實際執行時才決定。
當執行一條參考這些新建立物件的 DML 時,查詢就會重新編譯,然後產生新的執行計畫。
一般資料表與區域暫存資料表都可以在 Batch 中建立,用來保存中間結果集。
不過,因延遲物件解析而造成的陳述式重新編譯,在一般資料表與區域暫存資料表上的行為並不相同。
資料表上的重新編譯
CREATE OR ALTER PROC dbo.RecompileTable
AS
CREATE TABLE dbo.ProcTest1
(
C1 INT
);
SELECT *
FROM dbo.ProcTest1;
DROP TABLE dbo.ProcTest1;
像這種 sp,因為在第一次執行這個 sp 的時候,SQL Server 會在查詢真正執行之前先產生執行計畫。
但是此時,table proctest1 並不存在,因此所建立的執行計畫不會包含 select 的處理策略。
所以建立 table 之後,必須再進行重新編譯
他流程可以用剛剛建立的擴充事件去看
暫存資料表上的重新編譯
這個在現實運營上是一個更常見的情況
CREATE OR ALTER PROC dbo.RecompileProc
AS
CREATE TABLE #TempTable (C1 INT);
INSERT INTO #TempTable (C1)
VALUES (42);

如果建立這個預存程序,然後執行兩次,擴充事件會長這樣
可以看到中間有 Deferred compile
預存程序中的第一條陳述式會建立暫存資料表,第二條陳述式則會將資料插入其中。第二條陳述式必須延後到物件實際建立之後才能進行編譯。
然而,與上一節範例中的一般資料表不同,建立暫存資料表不會被視為結構描述變更。因此,不需要再次重新編譯,並且可以重複使用原本的執行計畫。
反覆強調,重新編譯對特定查詢可能帶來極大好處。如果資料已經發生足夠程度的變化,最佳化重新編譯就可以做出更好的執行計畫。
所以不是要無腦的避免重新編譯,只是有些程式的撰寫方式會造成不必要的重新編譯。
因此,遵循一些錯法,可以降低重新編譯發生的頻率 :
KEEPFIXED PLAN 提示。SET 選項。在 batch 或 sp 中使用 temp table,是很常見的作法。
實際上,甚至會看到有人在處理過程中還去修改結構描述或新增索引。
但,這些做法會影響執行計畫的有效性,會讓參考這些 temp table 的陳述式發生重新編譯,而其中大多數是由延遲編譯所造成的。
類似下面這種狀況
CREATE OR ALTER PROC dbo.TempTable
AS
-- 所有陳述式一開始都會先完成編譯
CREATE TABLE #MyTempTable
(
ID INT,
Dsc NVARCHAR(50)
);
-- 這條陳述式必須重新編譯
INSERT INTO #MyTempTable
(
ID,
Dsc
)
SELECT
pm.ProductModelID,
pm.Name
FROM Production.ProductModel AS pm;
-- 這條陳述式必須重新編譯
SELECT
mtt.ID,
mtt.Dsc
FROM #MyTempTable AS mtt;
CREATE CLUSTERED INDEX iTest
ON #MyTempTable (ID);
-- 建立索引會造成重新編譯
SELECT
mtt.ID,
mtt.Dsc
FROM #MyTempTable AS mtt;
CREATE TABLE #t2
(
c1 INT
);
-- 因建立新資料表而重新編譯
SELECT c1
FROM #t2;
記住,預存程序中的每一條陳述式,一開始都會取得一個執行計畫。儘管物件尚不存在,他也會先編譯,等到物件真的建立之後,他就又要編譯一次。
在大多數情況下,資料隨時間發生變化並導致統計資料更新時,查詢需要產生新的執行計畫。這表示重新編譯所帶來的成本,整體而言通常對系統是有益的。
所以再次強調,統計資料變更造成的重新編譯,是正確的。
然而,在某些情況下,即使統計資料已經更新,因為資料分布仍然相同,重新產生的執行計畫可能與原本完全一致。如果這種狀況頻繁發生,重新編譯所造成的負擔可能會非常明顯。
必須先說這種狀況非常少見,但是如果真的發生,可以用下列兩種方式處理由統計資料更新所引起的重新編譯 :
如果希望可以盡可能避免重新編譯,可以套用這個 KEEPFIXED PLAN 查詢提示。
這個可以讓查詢需要
IF
(
SELECT OBJECT_ID('dbo.Test1')
) IS NOT NULL
DROP TABLE dbo.Test1;
GO
CREATE TABLE dbo.Test1
(
C1 INT,
C2 CHAR(50)
);
INSERT INTO dbo.Test1
VALUES
(1, '2');
CREATE NONCLUSTERED INDEX IndexOne
ON dbo.Test1 (C1);
GO
-- 建立參考前述資料表的預存程序
CREATE OR ALTER PROC dbo.TestProc
AS
SELECT
t.C1,
t.C2
FROM dbo.Test1 AS t
WHERE t.C1 = 1
OPTION (KEEPFIXED PLAN);
GO
-- 在資料表只有 1 筆資料時,第一次執行預存程序
EXEC dbo.TestProc; -- 第一次執行
-- 新增大量資料列,以造成統計資料變更
WITH Nums
AS
(
SELECT 1 AS n
UNION ALL
SELECT Nums.n + 1
FROM Nums
WHERE Nums.n < 1000
)
INSERT INTO dbo.Test1
(
C1,
C2
)
SELECT
1,
Nums.n
FROM Nums
OPTION (MAXRECURSION 1000);
GO
-- 在統計資料發生變更後,再次執行預存程序
EXEC dbo.TestProc;
完全沒有重新編譯發生
任何查詢提示都應該在經過充分測試,並證明它確實是最佳解決方案之後才使用。
KEEPFIXED PLAN 可能會讓系統持續保留一個較差的執行計畫,而重新編譯後原本可能產生更好的計畫與更佳效能。
另一個在這裡可能有用的查詢提示是 KEEP PLAN。
這個提示是專門針對暫存資料表使用的。它會保留目前的執行計畫,直到統計資料更新所需的 500 筆資料列門檻被達到為止。
使用暫存資料表時,它可以幫助減少重新編譯的次數。
不過,它仍然具有與前面相同的注意事項。
可以選擇停用統計資料更新,範圍可以是整個資料庫,也可以只針對個別資料表
EXEC sys.sp_autostats 'dbo.Test1' ,'OFF';
現在,不論資料如何變更,這個資料表上的統計資料都不會更新。
這表示,不會有任何查詢因為資料變更而被標記為需要重新編譯。
再一次強調,這種做法可能會造成非常嚴重的問題。在實作之前,應進行充分測試,以確認它不會損害其他查詢的效能。
此外,如果你確實選擇停用自動統計資料更新,就應該規劃一套手動更新統計資料的流程,並以更可控的方式處理由此產生的重新編譯。
這跟 TEMP TABLE 很類似的東西,但差別在變數 TABLE 他不會有統計資料
所以不會遇到因為統計資料更新造成重新編譯的問題
DECLARE @count INT;
CREATE TABLE #TempTable
(
C1 INT PRIMARY KEY
);
SET @count = 1;
WHILE @count < 8
BEGIN
INSERT INTO #TempTable
(
C1
)
VALUES
(
@count
);
SELECT
tt.C1
FROM #TempTable AS tt
JOIN Production.ProductModel AS pm
ON pm.ProductModelID = tt.C1
WHERE tt.C1 < @count;
SET @count += 1;
END;
DROP TABLE #TempTable;
你可以在一個預存程序中宣告暫存資料表,然後在由第一個預存程序呼叫的第二個預存程序中,使用同一個暫存資料表。
在 SQL Server 2019 以前的版本中,以及 Azure SQL Database 以外的環境中,這種做法會導致查詢每次被呼叫時都發生重新編譯。
然而,由於資料庫引擎已經有所改變,2019以後不會再看到這些重新編譯。
CREATE OR ALTER PROC dbo.OuterProc
AS
CREATE TABLE #Scope
(
ID INT PRIMARY KEY,
ScopeName VARCHAR(50)
);
EXEC dbo.InnerProc;
GO
CREATE OR ALTER PROC dbo.InnerProc
AS
INSERT INTO #Scope
(
ID,
ScopeName
)
VALUES
(
1, -- ID - int
'InnerProc' -- ScopeName - varchar(50)
);
SELECT
s.ScopeName
FROM #Scope AS s;
GO
在執行預存程序時變更環境設定,會直接導致重新編譯。
為了符合 ANSI 相容性,一般建議將下列 SET 選項保持為 ON:
ARITHABORT
CONCAT_NULL_YIELDS_NULL
QUOTED_IDENTIFIER
ANSI_NULLS
ANSI_PADDING
ANSI_WARNINGS
NUMERIC_ROUNDABORT 則應設定為 OFF。
第一次執行這些查詢時,位於 SET 選項變更之後的陳述式會發生重新編譯。
不過,注意到第二次執行時沒有出現任何重新編譯。這是因為這些 SET 選項現在已經成為執行計畫的一部分,因此不再需要進一步重新編譯。
然而,對於內容相同的查詢,現在計畫快取中已經存在三個執行計畫。
另外值得注意的是,變更 SET NOCOUNT 環境設定不會造成重新編譯。
前面是幾種可以嘗試減少重新編譯次數的方法
但是有一些重新編譯是無法避免。在這種情況下,通常需要一些機制來控制重新編譯後產生的結果。
有四種選擇
這個在前面有稍微提到過
在後面介紹如何處理參數敏感型執行計畫的時候,還會再進一步說明
強制執行計畫不會阻止重新編譯的發生,只要符合前面列出的任何條件,執行計劃仍然會重新編譯。
但是,強制執行計畫可以讓我們控制重新編譯的結果。系統不會使用一個全新的執行計畫,而是使用你所選擇並強制指定的計畫。
這裡的前提是,該執行計畫沒有因程式碼或結構變更而失效。否則,這就是控制重新編譯結果的一種方式。
再說一次這不是提示,他英文是 QUERY HINT,但這個東西更像是一個命令,叫SQL SERVER 必須得這樣做。
前面有介紹過 KEEPFIXED PLAN 消除重新編譯,和 KEEP PLAN 減少 TEMP TABLE 重新編譯發生次數,那個用的就是 QUERY HINT。
許多可用的查詢提示,都直接與強制最佳化工具採用特定選擇有關。後續還會介紹多種不同的提示。不過,這裡有一個我特別想提出來說明的提示:OPTIMIZE FOR
OPTIMIZE FOR 提示可以讓你控制編譯過程中所使用的參數值。你可以搭配特定參數值使用 OPTIMIZE FOR,以取得針對該值產生的精確執行計畫。你也可以使用 OPTIMIZE FOR UNKNOWN,以取得較為通用的執行計畫。
--示範參數敏感 sp
--什麼是參數敏感以後再說
CREATE OR ALTER PROCEDURE dbo.CustomerList
@CustomerID INT
AS
SELECT
soh.SalesOrderNumber,
soh.OrderDate,
sod.OrderQty,
sod.LineTotal
FROM Sales.SalesOrderHeader AS soh
JOIN Sales.SalesOrderDetail AS sod
ON soh.SalesOrderID = sod.SalesOrderID
WHERE soh.CustomerID >= @CustomerID
OPTION (OPTIMIZE FOR (@CustomerID = 1));
這樣寫的話,這個預存程序中的查詢都會根據 OPTIMIZE FOR 提示中提供給 @CustomerID 的值,取得同一個執行計畫。
--然後再去跑這個看執行計畫
EXEC dbo.CustomerList
@CustomerID = 7920
WITH RECOMPILE;
EXEC dbo.CustomerList
@CustomerID = 30118
WITH RECOMPILE;
id = 7920 那個預估 121317 筆,實際回傳也是 121317 筆,簡單來說,這個計畫對這個參數而言是正確的。
但是 id = 30118 那個實際只回傳 289 筆資料,這強烈表示,第二個參數值原本可能可以用不同的執行計畫,只是因為 Query Hint 把他鎖在只能用這個執行計畫。
如果要用查詢提示,但又不想改程式碼的話,可以用這個計畫指南
CREATE OR ALTER PROCEDURE dbo.CustomerList
@CustomerID INT
AS
SELECT
soh.SalesOrderNumber,
soh.OrderDate,
sod.OrderQty,
sod.LineTotal
FROM Sales.SalesOrderHeader AS soh
JOIN Sales.SalesOrderDetail AS sod
ON soh.SalesOrderID = sod.SalesOrderID
WHERE soh.CustomerID >= @CustomerID;
然後今天判斷出要加查詢提示,但因為某些原因無法修改程式碼,此時就可以去建立一個計畫指南
sp_create_plan_guide
@name = N'MyGuide',
@stmt = N'SELECT soh.SalesOrderNumber,
soh.OrderDate,
sod.OrderQty,
sod.LineTotal
FROM Sales.SalesOrderHeader AS soh
JOIN Sales.SalesOrderDetail AS sod
ON soh.SalesOrderID = sod.SalesOrderID
WHERE soh.CustomerID >= @CustomerID;',
@type = N'OBJECT',
@module_or_batch = N'dbo.CustomerList',
@params = NULL,
@hints = N'OPTION (OPTIMIZE FOR (@CustomerID = 1))';
但是要注意,查詢文字、格式都要一樣,換行、空白那些的也是,全部都要一樣。
上面這種方式是物件指南,只有再物件 CustomerList 裡面才會發生
也是有另外一種 SQL 指南的類型
SELECT
soh.SalesOrderNumber,
soh.OrderDate,
sod.OrderQty,
sod.LineTotal
FROM Sales.SalesOrderHeader AS soh
JOIN Sales.SalesOrderDetail AS sod
ON soh.SalesOrderID = sod.SalesOrderID
WHERE soh.CustomerID >= 1;
EXECUTE sp_create_plan_guide
@name = N'MyGoodSQLGuide',
@stmt = N'SELECT
soh.SalesOrderNumber,
soh.OrderDate,
sod.OrderQty,
sod.LineTotal
FROM Sales.SalesOrderHeader AS soh
JOIN Sales.SalesOrderDetail AS sod
ON soh.SalesOrderID = sod.SalesOrderID
WHERE soh.CustomerID >= 1;',
@type = N'SQL',
@module_or_batch = NULL,
@params = NULL,
@hints = N'OPTION
(
TABLE HINT
(
soh,
FORCESEEK
)
)';
--移除指南
EXECUTE sp_control_plan_guide
@operation = 'Drop',
@name = N'MyGoodSQLGuide';
EXECUTE sp_control_plan_guide
@operation = 'Drop',
@name = N'MyGuide';
| Recompile 原因 | 說明 | 是否正常 |
|---|---|---|
| Statistics changed | 統計資料更新導致重新編譯 | 正常 |
| Schema changed | Table / Index / Column 結構變更 | 正常 |
| Temp table changed | 暫存表結構或資料量變化 | 常見 |
| SET option changed | SET 選項不同導致 plan 不能重用 | 要注意 |
| OPTION (RECOMPILE) | 明確要求每次重新編譯 | 人為控制 |
| Plan removed from cache | 記憶體壓力或清除 cache | 視情況 |
| Deferred compile | Temp table / table variable 延遲編譯 | 視情況 |